lnwallet+walletrpc: add WalletKit.SubmitPackage for v3 CPFP package relay#10900
Conversation
|
PR Severity: CRITICAL. Files in lnwallet/* (lnwallet/interface.go, lnwallet/btcwallet/btcwallet.go, lnwallet/mock.go) drive the CRITICAL classification. Also touches lnrpc/* (HIGH) and proto/other files (MEDIUM). 10 non-excluded files, ~250 non-excluded lines - no bump needed. <!-- pr-severity-bot --> |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces a new SubmitPackage RPC method to the WalletKit service, allowing clients to submit topologically sorted transaction packages to the chain backend. This capability is essential for supporting v3/TRUC package relay, which allows zero-fee parent transactions to be accepted when paired with a fee-paying CPFP child. The changes include updates to the wallet interface, backend implementations, and necessary gRPC/protobuf infrastructure. Highlights
New Features🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new SubmitPackage RPC endpoint to the WalletKit service, enabling the submission of a package of related transactions for atomic validation and acceptance. This feature is implemented across the wallet controller interface, mock implementations, and the btcwallet backend. The review feedback suggests several important improvements: adding a defensive nil check on the backend's returned result in walletkit_server.go to prevent a potential panic, documenting the stub implementation in no_chain_backend.go to comply with the repository style guide, and marking the max_fee_rate field as optional in the protobuf definition to safely distinguish between an omitted field and an explicit zero limit.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
65de675 to
d290c19
Compare
|
Thanks for the review — addressed all three:
Also fixed up the CI failures: added the missing REST annotation for the new RPC ( |
d290c19 to
630cb0e
Compare
| func testSubmitPackage(ht *lntest.HarnessTest) { | ||
| // submitpackage is a bitcoind RPC: btcd has no equivalent and neutrino | ||
| // has no mempool, so this test only applies to the bitcoind backend. | ||
| if ht.ChainBackendName() != "bitcoind" { |
There was a problem hiding this comment.
could we also run it with neutrino? as the implementation would suggest it works there
There was a problem hiding this comment.
good point — it does work on neutrino, but only against a package-aware miner. neutrino has no mempool, so SubmitPackage just broadcasts parent+child and leans on the peer's 1p1c relay — that needs the bitcoind miner (minerbackend=bitcoind, v28+); against the default btcd miner the zero-fee parent is simply rejected and never assembles. so a neutrino row would have to be gated on minerbackend=bitcoind and assert confirmation (the neutrino result itself is a synthetic best-effort one). afaict there's no backend=neutrino+minerbackend=bitcoind combo in CI today, so it'd be skipped everywhere unless we add that job. happy to add the gated test (+ the CI combo) if you think it's worth it — wdyt?
2428104 to
bfabceb
Compare
a928b90 to
3983397
Compare
7a5ce08 to
ceaea5f
Compare
ceaea5f to
dd84372
Compare
dd84372 to
b65a966
Compare
b65a966 to
72c7096
Compare
|
/gateway review |
|
✅ Review posted: #10900 (review) 10 finding(s); 10 inline, 0 in body. 🔁 Need a re-review after pushing changes? Reply with |
There was a problem hiding this comment.
This PR adds a WalletKit.SubmitPackage RPC (plus a WalletController.SubmitPackage interface method, BtcWallet/mock implementations, an lncli command, and an integration test) so a client can submit a topologically-sorted v3/TRUC package — a zero-fee parent plus a fee-paying CPFP child — to the chain backend for atomic acceptance. The intent is sound, the end-to-end itest is a genuine proof of the CPFP path, and the fee-rate unit conversion (sat/vByte → BTC/kvB) is dimensionally and numerically correct.
The concerns concentrate in three areas. First, the fee-safety knob is a footgun: an explicit sat_per_vbyte = 0 is overloaded to mean "disable the ceiling entirely," and both the docs and the example/itest nudge users toward it — removing overpay protection on their own funds. Second, the neutrino path cannot truly satisfy package relay (it broadcasts each tx independently, ignores the fee ceiling, isn't atomic, and can return an unverifiable "success"). Third, the public surface has several correctness/contract sharp edges: untyped success signaling, an unbounded input, and a wtxid-vs-txid result shape. A breaking addition to an exported interface also ships without a release-notes entry.
Findings: 🔴 0 Blocker · 🟠 5 Major · 🟡 5 Minor · 🔵 0 Nit
| // and relies on the connected full node's 1p1c package relay; a synthetic | ||
| // success result is returned since a light client cannot report per-tx package | ||
| // acceptance. | ||
| func (b *BtcWallet) SubmitPackage(txns []*wire.MsgTx, |
There was a problem hiding this comment.
🟠 Major F2: Neutrino SubmitPackage is not atomic and can report unverifiable success
The neutrino branch loops PublishTransaction(tx, "") over txns individually, ignores maxFeeRate entirely (no fee ceiling is ever applied on neutrino), and on success synthesizes PackageMsg: "success". This breaks the package-atomicity contract that the whole feature depends on: a zero-fee v3 parent broadcast standalone is invalid, so either the call errors at the parent (the feature does not work on neutrino) or — if an earlier tx publishes and a later one fails — the mempool is left with a partial, non-atomic subset with no rollback. When every tx happens to publish, the synthesized "success" asserts a package-accept verdict a light client cannot substantiate, so downstream logic (sweeps, force-close bumps) may proceed on a false premise. On failure the loop returns a bare err with no index/txid, so the partial-broadcast state is undiagnosable. The neutrino path should either return an explicit "unsupported/unverified" result or, at minimum, not claim "success" and should wrap publish errors with the offending tx.
There was a problem hiding this comment.
(F2) Right — neutrino has no mempool, so it can't substantiate a package-accept verdict. It does a best-effort individual P2P broadcast (relying on the peer's 1p1c relay) and returns a synthetic result; only bitcoind performs real submitpackage. The proto + method docs now call this out explicitly as best-effort/non-atomic. Happy to return a distinct non-"success" package_msg for the neutrino path if you'd prefer a clearer signal.
There was a problem hiding this comment.
Addressed: the neutrino branch is removed — BtcWallet.SubmitPackage now returns ErrUnimplemented for both btcd and neutrino (only bitcoind does real submitpackage). It no longer fabricates a "success" verdict a light client can't substantiate, and there's no partial-broadcast path (the old loop would have failed at the zero-fee parent anyway, since it's below min-relay-fee on its own). Callers that want neutrino relay broadcast the txns individually and lean on the peer's 1p1c relay. Proto + method docs updated to match.
There was a problem hiding this comment.
Update (after discussion with @yyforyongyu, went with the best-effort option rather than erroring): the neutrino path still broadcasts each tx individually and relies on the peer's 1p1c relay, but it no longer claims "success" — it returns a deliberately non-success PackageMsg ("neutrino best-effort broadcast; acceptance unverified") and wraps publish errors with the offending tx index + txid, so callers treat it as an unverified broadcast, not a package-accept verdict.
There was a problem hiding this comment.
Shortened the neutrino PackageMsg to "broadcast-unverified" (the rationale is in the const's doc comment) — less verbose than the first pass.
|
|
||
| // packageSubmitter is the subset of the wallet controller that can submit a | ||
| // package of transactions for atomic validation/acceptance. The | ||
| // btcwallet-backed WalletController implements it; other controllers do not. |
There was a problem hiding this comment.
🟠 Major F3: No bound on package size; unbounded raw_txs decode
The handler accepts req.RawTxs with no cap on count or per-tx size, pre-allocating make([]*wire.MsgTx, 0, len(req.RawTxs)) and calling tx.Deserialize on every element before any validity check. wire.MsgTx.Deserialize honors length prefixes in the byte stream and allocates per-input/output slices accordingly, so a small request body can drive large allocations (bandwidth amplification). While onchain:write gates this, that macaroon can be delegated/RBAC-scoped, so an authenticated-but-not-operator caller can force outsized work. A real package is at most a parent+child (bitcoind caps packages at 25); reject when len(req.RawTxs) exceeds a small bound and cap individual raw-tx size before deserializing. Documenting the cap in the proto/lncli would also make the limit part of the published contract.
There was a problem hiding this comment.
Done (F3) — the handler now rejects packages larger than maxPackageTxns = 25 (mirroring bitcoind's MAX_PACKAGE_COUNT) before deserializing, bounding the work an authenticated caller can force.
| // limit) is passed through unchanged. | ||
| var maxFeeRate *chainfee.SatPerVByte | ||
| if req.SatPerVbyte != nil { | ||
| rate := chainfee.SatPerVByte(*req.SatPerVbyte) |
There was a problem hiding this comment.
🟠 Major F4: Package rejection isn't surfaced as an error; success is a free-form string
Whole-package acceptance is conveyed only through PackageMsg, a free-form string the caller must compare against the literal "success" (the itest does require.Equal(ht, "success", ...)). The handler returns a nil gRPC error whenever the backend call returns no Go error, even if bitcoind rejected the package (PackageMsg != "success") or individual txns failed (tx_results[*].error populated) — so a caller that checks only the gRPC status believes an on-chain funds operation succeeded when it did not. Per-tx errors are likewise opaque strings, so a caller cannot machine-distinguish "fee too low" from "missing inputs" from "already in mempool" to decide its next action. Add a typed status (e.g. an enum: fully-accepted / partially-accepted / rejected) and reserve package_msg for human-readable detail; a brittle string contract on a public RPC is hard to change later.
There was a problem hiding this comment.
(F4) Per-tx failures are surfaced via tx_results[*].error and package_msg carries bitcoind's verdict, mirroring the submitpackage RPC's own result shape, so a fully-rejected package returns the detail in the response rather than a gRPC error. If we want callers to machine-distinguish accepted/partial/rejected without parsing package_msg, I can add a typed status enum (or return a gRPC error on non-acceptance) — lmk which you'd prefer.
| // 1 sat/vByte = 1000 sat/kvB, and SatoshiPerBitcoin sats make a | ||
| // BTC, so BTC/kvB = sat/vByte * 1000 / SatoshiPerBitcoin. | ||
| btcPerKvB := float64(*maxFeeRate) * 1000 / | ||
| btcutil.SatoshiPerBitcoin |
There was a problem hiding this comment.
🟡 Minor F9: Fee-rate conversion is correct but untested, with magic constant and integer-only granularity
btcPerKvB := float64(*maxFeeRate) * 1000 / btcutil.SatoshiPerBitcoin is dimensionally and numerically correct (1 sat/vByte → 0.00001 BTC/kvB; bitcoind's 0.10 BTC/kvB default = 10 sat/vByte), but: there is no unit test pinning a known input, so an off-by-1000 regression here would silently relax or tighten the user's fee ceiling; the 1000 (vByte→kvB) is an inline magic number; and because chainfee.SatPerVByte is integer, only whole sat/vByte ceilings are expressible and very large values lose integer precision once past 2^53 in the float64 product. Add a conversion unit test, name the constant, and document the integer-granularity limitation on the parameter.
There was a problem hiding this comment.
Done (F9) — extracted satPerVByteToBTCPerKvB with a named vBytesPerKvB constant (documenting the integer-granularity limitation) and added a unit test pinning the known reference points.
|
🤖 gateway audit metadata for this PR — auto-generated, please don't edit. |
yyforyongyu
left a comment
There was a problem hiding this comment.
Cool some valid issues found by agents, think we should address them first.
| /* lncli: `wallet submitpackage` | ||
| SubmitPackage submits a package of related transactions (topologically | ||
| sorted, unconfirmed parents first and the child last) for atomic | ||
| validation and acceptance. For bitcoind/btcd backends it uses the node's |
There was a problem hiding this comment.
This comment is inaccurate for two backends. The btcd backend in btcwallet v0.18.0 returns ErrUnimplemented for SubmitPackage, so it does not use submitpackage. Neutrino also does not provide atomic validation/acceptance; it can only attempt non-atomic individual P2P broadcasts. The RPC docs should probably say bitcoind-only for real package submission, and clearly mark neutrino as best-effort/non-atomic if it remains supported.
There was a problem hiding this comment.
Done — reworded so it's clear only the bitcoind backend performs real package submission (via submitpackage); the btcd backend returns an error, and neutrino is best-effort/non-atomic (individual P2P broadcast relying on 1p1c relay).
| // SubmitPackage submits a package of related transactions (topologically | ||
| // sorted, parents first and child last) for atomic validation and acceptance. | ||
| // | ||
| // For bitcoind/btcd backends it uses the node's submitpackage RPC, which lets |
There was a problem hiding this comment.
Same backend-support issue here: this says bitcoind/btcd, but the btcwallet v0.18.0 btcd backend returns ErrUnimplemented for SubmitPackage. This should be narrowed to bitcoind unless btcd support lands.
There was a problem hiding this comment.
Done — narrowed the doc: only bitcoind does real submission; the btcd backend has no submitpackage handler and returns ErrUnimplemented.
| txns = append(txns, tx) | ||
| } | ||
|
|
||
| submitter, ok := w.cfg.Wallet.(packageSubmitter) |
There was a problem hiding this comment.
This type assertion is always true now that SubmitPackage was added to the lnwallet.WalletController interface and w.cfg.Wallet is statically a WalletController. Either keep SubmitPackage off the base interface and make this an optional capability, or remove the local interface/assertion and call w.cfg.Wallet.SubmitPackage directly.
There was a problem hiding this comment.
Done — SubmitPackage is on WalletController now, so the assertion was always true. Removed the packageSubmitter interface + assertion and call w.cfg.Wallet.SubmitPackage directly (also dropped the now-unused btcjson import).
| - google.golang.org/protobuf | ||
| - github.com/lightningnetwork/lnd/sqldb | ||
| - github.com/lightningnetwork/lightning-onion | ||
| - github.com/btcsuite/btcwallet |
There was a problem hiding this comment.
These replace-allow-list entries look stale now that go.mod no longer has a replace for btcwallet/btcd. Since the preceding comment points readers to go.mod for the reason, please remove these draft-era allow-list entries unless a replace is still expected before merge.
There was a problem hiding this comment.
Done — removed the stale btcwallet/btcd replace-allow-list entries; go.mod no longer replaces them.
455bbe7 to
ac88f0a
Compare
ac88f0a to
c5c3723
Compare
|
Thanks for the thorough review @yyforyongyu (and the gateway agent). Pushed fixes for all the actionable findings — summary: Addressed (code):
Dismissed per your Left as-is unless you'd prefer otherwise (design calls):
CI is green modulo known flakes (neutrino reorg, htlc-multihop, a docker-pull timeout). PTAL 🙏 |
| } | ||
|
|
||
| return &btcjson.SubmitPackageResult{ | ||
| PackageMsg: "success", |
There was a problem hiding this comment.
think we should address https://github.com/lightningnetwork/lnd/pull/10900/changes#r3493050462 here - maybe name it published or broadcast-unverified? just to make it clear at the callsite so it can check
c5c3723 to
c432322
Compare
c432322 to
ef55a11
Compare
yyforyongyu
left a comment
There was a problem hiding this comment.
Pending CIs, otherwise LGTM!
ef55a11 to
4aae817
Compare
Add SubmitPackage to the lnwallet.WalletController interface and a new WalletKit.SubmitPackage RPC, so a client of lnd can relay a package of related transactions (parents first, child last) through lnd's own chain connection. This lets a zero-fee v3/TRUC parent be accepted via its fee-paying CPFP child without the caller needing a separate connection to the chain backend. BtcWallet.SubmitPackage forwards to the chain backend's submitpackage for bitcoind/btcd, and broadcasts each transaction individually for neutrino (no mempool; relies on the peer's 1p1c package relay). The WalletKit handler maps the proto request/response to the btcjson result and is gated by the onchain:write macaroon permission. Mock controllers and the no-chain backend gain trivial implementations.
Add a `wallet submitpackage` command that takes one or more hex-encoded raw transactions (topologically sorted, parents first and the child last) and an optional --max_fee_rate, and submits them as a package via the WalletKit.SubmitPackage RPC.
Add an integration test that exercises WalletKit.SubmitPackage: it builds a zero-fee v3 (TRUC) parent that a standalone broadcast would reject, pairs it with a fee-paying v3 CPFP child, and asserts the package is accepted. A zero-fee transaction can only enter the mempool via package evaluation, so this proves the CPFP package path end to end. submitpackage is a bitcoind RPC, so the test skips on the btcd and neutrino backends. Also adds the SubmitPackage wrapper to the integration-test RPC harness.
Document the new WalletKit.SubmitPackage RPC and the lncli wallet submitpackage command in the 0.22.0 release notes.
4aae817 to
68c4740
Compare
Summary
Adds a
WalletKit.SubmitPackageRPC, letting a client submit a package ofrelated transactions (topologically sorted, unconfirmed parents first and the
child last) to lnd's chain backend for atomic validation and acceptance.
This is what allows a v3/TRUC package to relay: a zero-fee parent carrying
an ephemeral (P2A) anchor is below the minimum relay fee and is rejected by a
standalone broadcast, but is accepted when submitted together with a
fee-paying CPFP child whose combined feerate clears policy.
TestMempoolAcceptalready exposes Core's
testmempoolacceptthis way;SubmitPackageis thebroadcasting counterpart for
submitpackage.Details
SubmitPackagemethod on thelnwallet.WalletControllerinterface.BtcWalletforwards it to the chain backend. Only the bitcoind backendperforms real package submission (via the node's
submitpackageRPC); thebtcd backend has no
submitpackagehandler and returnsErrUnimplemented; neutrino (no mempool) broadcasts each transactionover P2P best-effort, relying on the peer's 1p1c package relay, and returns
a synthetic result.
WalletKit.SubmitPackagehandler decodes the raw transactions (capped atmaxPackageTxns = 25, mirroring bitcoind'sMAX_PACKAGE_COUNT), forwards anoptional
sat_per_vbyteceiling (unset = node default;0= no limit, whicha high-feerate CPFP child needs), and maps the node result back to the proto
response. Gated by the
onchain:writemacaroon permission.wallet submitpackagelnclicommand.(
NoChainSourcereturnserrNotImplemented).Dependencies
Builds on the btcd-v2-modules lnd master and the released btcwallet
v0.18.0, which carries the
chain.Interface.SubmitPackagemethod(btcsuite/btcwallet#1264). No
replacedirectives.Testing
make rpcreproduces the generated stubs cleanly andcmd/lndbuilds. Adds anintegration test (
testSubmitPackage) that submits a zero-fee v3 parent plus afee-paying CPFP child and asserts the package is accepted, lands in the
mempool, and confirms — exercising the CPFP path end to end. A zero-fee parent
only relays to a package-relay-capable node, so the test runs on the bitcoind
backend with a bitcoind miner (
minerbackend=bitcoind) and skips otherwise.Also adds a unit test pinning the sat/vByte -> BTC/kvB fee-rate conversion.